﻿using System;
using System.Linq;
using System.Net;
using System.Text;
using System.Web;
using Newtonsoft.Json;
using FBGraph.Web.Configuration;

namespace FBGraph.Web.Security
{
    /// <summary>Extension methods to handle server-side FBGraph authentication.</summary>
    public static class FBGraphAuthenticationHelper
    {
        private const String AUTH_URL_FORMAT = "https://graph.facebook.com/oauth/authorize?client_id={0}&redirect_uri={1}&type=web_server&display={2}&scope={3}";
        private const String AUTH_REQUEST_COOKIE_FORMAT = "AuthRequest_{0:n}";
        private const String AUTH_TOKEN_URL_FORMAT = "https://graph.facebook.com/oauth/access_token?client_id={0}&redirect_uri={1}&client_secret={2}&code={3}";

        private const String APP_USER_COOKIE_NAME = "AppUser";
        private const String EXTENDED_PERMISSIONS_COOKIE_NAME = "Permissions";

        private const String ERROR_EXPIRED_CODE = "OAuthException: Code was invalid or expired.";

        /// <summary>Generates an authorization redirectUri URL given the specified parameters</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        /// <param name="postAuthorizeUrl">The url that will handle the callback from the FBGraph OAuth provider.</param>
        /// <param name="returnUrl">The url that the user will be redirected to upon authentication.</param>
        /// <param name="popup">A value representing whether the authentication dialog is being presented in a popup window.</param>
        /// <param name="scope">A mapping of <see cref="ExtendedPermission" />s that should be requested, if any.</param>
        /// <returns>A url that is used to redirectUri the user to in order to accomplish authentication.</returns>
        /// <remarks>
        /// <para>
        /// Information about the request is serialized into JSON and stored in a cookie. This is done instead of encoding the data on the request to avoid
        /// needing to recontruct the redirectUri URI while handling the callback. By storing the data in a cookie, all that is required to be sent to the FBGraph
        /// OAuth provider is an identifier. This will allow adding additional information later on without requiring changes (and potential introduction of bugs) 
        /// to the code that handles the callback.
        /// </para>
        /// </remarks>
        public static String PrepareAuthUrl(this IFBGraphHttpContext context, String postAuthorizeUrl, String returnUrl, ExtendedPermission? scope, Boolean? popup)
        {
            var requestID = Guid.NewGuid(); // local identifier for the request data

            var redirectUri = new UriBuilder(context.HttpContext.Request.Url);
            redirectUri.Path = String.Format("{0}/{1:n}", postAuthorizeUrl, requestID);
            redirectUri.Query = null;

            var requestData = new AuthRequestData
            {
                ClientID = context.ClientID,
                Scope = scope,
                RequestID = requestID,
                RedirectUri = redirectUri.Uri.ToString(),
                ReturnUrl = returnUrl ?? context.HttpContext.Request.UrlReferrer.ToString()
            };

            var authRequestCookie = new HttpCookie(String.Format(AUTH_REQUEST_COOKIE_FORMAT, requestID))
            {
                Value = JsonConvert.SerializeObject(requestData),
                Expires = DateTime.Now.AddSeconds(30)
            };
            context.Cookies.Set(authRequestCookie);

            var authUrl = String.Format(AUTH_URL_FORMAT,
                context.ClientID, redirectUri.Uri,
                (popup ?? false) ? "popup" : String.Empty,
                scope.HasValue ? String.Join(",", scope.Value.ToFieldNames()) : String.Empty);

            return authUrl;
        }
        /// <summary>Handles the callback from Facebook's OAuth provider, fetches an <see cref="AccessToken" /> and stores it in a cookie.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        /// <param name="code">The <c>code</c> parameter from the OAuth callback.</param>
        /// <param name="requestID">The id assigned by <c>FBGraph.NET</c> to the authentication request referencing the cookie storing information about the request.</param>
        /// <param name="requestData">An <see cref="AuthRequestData" /> object containing data about the authorization request.</param>
        /// <returns>The url the user should be redirected to.</returns>
        public static Boolean ProcessAuthenticationCallback(this IFBGraphHttpContext context, String code, Guid requestID, out AuthRequestData requestData)
        {
            var cookie = context.Cookies[String.Format(AUTH_REQUEST_COOKIE_FORMAT, requestID)];

            if (cookie == null) throw new Exception("Received a stray PostAuthorize request");
            requestData = JsonConvert.DeserializeObject<AuthRequestData>(cookie.Value);
            requestData.Code = code;

            return ProcessAuthenticationCallback(context, requestData);
        }

        /// <summary>Handles the callback from Facebook's OAuth provider, fetches an <see cref="AccessToken" /> and stores it in a cookie.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        /// <param name="requestData">An <see cref="AuthRequestData" /> object containing data about the authorization request.</param>
        /// <returns>The url the user should be redirected to.</returns>
        private static Boolean ProcessAuthenticationCallback(IFBGraphHttpContext context, AuthRequestData requestData)
        {
            // fetch the access token from the FBGraph OAuth provider
            String content;
            using (var webClient = new WebClient())
            {
                try
                {
                    var url = String.Format(AUTH_TOKEN_URL_FORMAT, context.ClientID, requestData.RedirectUri, context.ClientSecret, requestData.Code);
                    content = webClient.DownloadString(url);
                }
                catch (WebException ex)
                {
                    var fgex = new FBGraphApiException(ex);
                    if (fgex.Message == ERROR_EXPIRED_CODE)
                    {
                        context.RemoveApplicationUserCookie();
                        return false;
                    }
                    else throw fgex;
                }
            }

            // extract the access token data from the response
            var pairs = content.Split('&');
            var values = (
                from p in pairs
                select p.Split('=')).ToDictionary(p => p[0], p => p[1]);

            // the OAuth 2.0 docs say that the duration should be sent as "expires_in", but Facebook sends it as just "expires"
            // both are left in for now in case Facebook decides to "fix" it.
            String strExpires = null;
            if (values.ContainsKey("expires")) strExpires = values["expires"];
            else if (values.ContainsKey("expires_in")) strExpires = values["expires_in"];

            // missing expiration parameter means it doesn't expire
            var expires = strExpires == null ? DateTime.MaxValue : DateTime.Now.AddSeconds(Int32.Parse(strExpires));

            context.SetAccessToken(values["access_token"], expires);
            context.SetApplicationUserCookie(requestData);

            if (requestData.Scope.HasValue) context.AppendExtendedPermissions(requestData.Scope.Value);

            return true;
        }

        private static void SetApplicationUserCookie(this IFBGraphHttpContext context, AuthRequestData requestData)
        {
            if (context.IsAuthorized)
            {
                context.Cookies.Set(APP_USER_COOKIE_NAME, requestData, DateTime.Now.AddYears(10));
            }
        }

        /// <summary>Removes the application user cookie, which contains application-specific information about a user.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        public static void RemoveApplicationUserCookie(this IFBGraphHttpContext context)
        {
            context.Cookies.Remove(APP_USER_COOKIE_NAME);
        }

        /// <summary>Gets a <see cref="Boolean" /> value representing whether an application user cookie, which contains application-specific information about a user, has been set.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        /// <returns><c>true</c> if the cookie exists; otherwise, <c>false</c>.</returns>
        public static Boolean HasApplicationUserCookie(this IFBGraphHttpContext context)
        {
            return context.Cookies.Contains(APP_USER_COOKIE_NAME);
        }

        private static AuthRequestData GetApplicationUserCookie(this IFBGraphHttpContext context)
        {
            return context.Cookies.Get<AuthRequestData>(APP_USER_COOKIE_NAME);
        }

        /// <summary>Attempts to refresh the user's access token.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        /// <returns><c>true</c> if the refresh was successful or if a refresh was not required; <c>false</c> if there was not enough information to attempt a refresh
        /// or if the request to refresh the access token failed.</returns>
        public static Boolean RefreshAccessToken(this IFBGraphHttpContext context)
        {
            if (context.AccessToken.HasValue && context.AccessToken.Value.Expires > DateTime.Now) return true; // token is valid, refresh not required
            else if (!context.HasApplicationUserCookie()) return false; // user has no cached token data, automatic refresh not possible
            else
            {
                var requestData = context.GetApplicationUserCookie();
                return ProcessAuthenticationCallback(context, requestData);
            }
        }

        /// <summary>Gets the set of known <see cref="ExtendedPermission" />s for the current user.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        /// <returns>A mapping of <see cref="ExtendedPermission" /> representing the currently known set of extended permissions.</returns>
        public static ExtendedPermission GetExtendedPermissions(this IFBGraphHttpContext context)
        {
            return context.Cookies.Get<ExtendedPermission>(EXTENDED_PERMISSIONS_COOKIE_NAME);
        }

        /// <summary>Removes the cookie that persists the user's extended permissions.</summary>
        /// <param name="context">A reference to the current <see cref="IFBGraphHttpContext" />.</param>
        public static void RemoveExtendedPermissions(this IFBGraphHttpContext context)
        {
            context.Cookies.Remove(EXTENDED_PERMISSIONS_COOKIE_NAME);
        }

        private static void AppendExtendedPermissions(this IFBGraphHttpContext context, ExtendedPermission scope)
        {
            if (context.Cookies.Contains(EXTENDED_PERMISSIONS_COOKIE_NAME))
            {
                var existingScope = context.Cookies.Get<ExtendedPermission>(EXTENDED_PERMISSIONS_COOKIE_NAME);
                scope |= existingScope;
            }

            context.Cookies.Set(EXTENDED_PERMISSIONS_COOKIE_NAME, scope, DateTime.Now.AddYears(10));
        }
    }
}